iT邦幫忙

2026 iThome 鐵人賽

DAY 22
0
Modern Web

用 Astro 打造 Content-first 前端網站:30 天從靜態內容到會員、資料庫與選型(3rd)系列 第 22

內容站要開始存資料,資料庫怎麼接才不會被平台綁死?

  • 分享至 

  • xImage
  •  

Day 21 那張 feedback 表單,現在收得到、驗得過,但送出後就沒了:handler 只驗證、只回傳,沒把資料留下來。這篇要把它接上資料層,讓每一筆 feedback 真的存進去、之後查得回來、算得出平均。

接資料庫之前,得先決定要用哪一個,因為這個選擇會影響後續部署。資料層如果只支援單一平台,日後換部署平台時,app 的資料存取方式也得跟著改。若要以 Cloudflare 為主要部署平台、同時保留轉到 Vercel 的可能,資料庫的連線方式也必須兩邊都能用。

選擇判準:資料庫有兩種接法

如果你哪天可能換部署平台,就別用「只在那個平台裡才連得到」的資料庫。

資料庫接進 app,大致分兩種接法:

  • 綁定(binding):資料庫住在雲平台裡,你的程式靠平台給的一個 handle(像 env.DB)去用它。它沒有對外的網址、沒有連線字串,只有跑在那個平台上的程式才叫得動。
  • 連線字串:資料庫在平台外、走 HTTPS,你的程式拿一組網址加 token 去連。誰能連不看你跑在哪,只看拿不拿得到那組網址和 token。

Cloudflare 有自家的 SQLite 資料庫 D1,走的是綁定;這篇要用的 Turso,走的是連線字串。放在一起對照:

Cloudflare D1 Turso / libSQL
存取方式 Workers/Pages 的 binding(env.DB 連線字串 + token,走 HTTPS
有沒有對外連線位址 沒有,資料庫只在平台內部 有(libsql://…turso.io
能從哪些 runtime 連 主要是 Cloudflare Workers/Pages Node、容器、serverless、Workers、Vercel…
換部署平台時 資料層要跟著換架構 同一份程式照連

(D1 也有一個管理用的 REST API 能跑查詢,但那是工具/管理用途、有流量限制,不是拿來當線上低延遲資料層的路徑。)

需要在 Cloudflare 與 Vercel 之間保留選擇,就用 Turso。若專案確定只部署在 Cloudflare,D1 的綁定能少一層連線設定。

Turso 是什麼?

Turso 是把 SQLite 託管起來、開一個 HTTPS 端點讓你連的資料庫服務。你不用自己顧一台資料庫機器,註冊後拿到一個 libsql://…turso.io 的網址和一組 token 就能連。

Turso 底層使用 libSQL,它是 SQLite 的分支。因此,原有的 SQLite/SQL 知識仍可沿用,不必另學一套資料庫。Turso 走 HTTP,Cloudflare Workers 這類無法開啟傳統資料庫連線的 edge 環境也能連線。

三個工具各自負責什麼

接下來會用到三個工具:

  • Drizzle ORM:負責查詢。你用 TypeScript 寫 db.select().from(feedback),Drizzle 會將它轉成 SQL 送往資料庫,查詢結果自動帶型別。Drizzle 跑在 app 裡。(ORM 就是「讓你用寫程式的方式操作資料庫、不必手拼 SQL 字串」的工具;Drizzle 的特色是薄、貼近 SQL。)
  • drizzle-kit:資料表遷移用的命令列工具。它讀你的 schema、產出 migration SQL、套用到資料庫。它跑在你的開發機或 CI 的 Node 環境,不會進到 app 的正式程式裡。
  • @libsql/client:負責連上 Turso,是 Drizzle 與資料庫之間的連線 driver。

schema 只需寫一次:drizzle-kit 用它建立資料表,Drizzle 則在 app 裡用它查詢。

從 schema 建立資料表

先裝套件。app 要用的是 drizzle-orm@libsql/client,遷移工具 drizzle-kit 只在開發時用、裝成 devDependency:

npm install drizzle-orm @libsql/client
npm install -D drizzle-kit

版本基準(2026-07-21 查證):drizzle-orm 0.45.2、@libsql/client 0.17.4、drizzle-kit 0.31.10。資料層這類套件迭代快,實作時以官方文件當下版本為準。

接著定義 schema。這份定義與 runtime 無關,欄位對應 Day 21 那張表單收的資料(評分、留言、哪一篇):

// src/db/schema.ts
import { sql } from 'drizzle-orm';
import { integer, sqliteTable, text } from 'drizzle-orm/sqlite-core';

export const feedback = sqliteTable('feedback', {
  id: integer('id').primaryKey({ autoIncrement: true }),
  slug: text('slug').notNull(),          // 哪一篇文章的 feedback
  rating: integer('rating').notNull(),   // 1–5,範圍在 action 的 Zod 已擋
  message: text('message').notNull(),
  createdAt: text('created_at').notNull().default(sql`(CURRENT_TIMESTAMP)`),
});

// 讓 insert / select 兩端都拿到型別,不用自己手寫 interface
export type InsertFeedback = typeof feedback.$inferInsert;
export type SelectFeedback = typeof feedback.$inferSelect;

然後設定 drizzle-kit,指定 schema 路徑與資料庫連線。連 Turso 時,dialect 要用 'turso'

// drizzle.config.ts
import { defineConfig } from 'drizzle-kit';

export default defineConfig({
  schema: './src/db/schema.ts',
  out: './migrations',
  dialect: 'turso',   // 連 Turso 用 'turso';'sqlite' 是給本機檔案型的
  dbCredentials: {
    url: process.env.TURSO_DATABASE_URL ?? 'file:local.db',
    authToken: process.env.TURSO_AUTH_TOKEN,
  },
});

drizzle-kit 跑在開發機的 Node 環境,因此這裡可以使用 process.env。app 的 runtime 有不同的 secret 讀取方式,連線時不能照搬這段設定。未設定 url 時,設定會退回本機的 file:local.db,不必先建立帳號也能在本機試跑。

最後產生並套用 migration。migration 是資料庫的「schema 版本控制」:修改 schema 後,產生的 migration 會記錄該次變更的 SQL;套用後,資料庫才會有對應的表:

npx drizzle-kit generate   # 把 schema 變成 SQL migration 檔
npx drizzle-kit migrate    # 套用(push 是開發期直推、跳過檔案,正式流程用 migrate)

generate 產生的 SQL 如下(migrations/0000_*.sql),欄位與上面的 schema 一一對應:

CREATE TABLE `feedback` (
	`id` integer PRIMARY KEY AUTOINCREMENT NOT NULL,
	`slug` text NOT NULL,
	`rating` integer NOT NULL,
	`message` text NOT NULL,
	`created_at` text DEFAULT (CURRENT_TIMESTAMP) NOT NULL
);

套用 migration 後,資料庫裡就有了這張表。

連線:Workers runtime 的兩個限制

app 要連 Turso,寫一個工廠函式,需要時才建連線:

// src/db/index.ts
import { drizzle } from 'drizzle-orm/libsql/web';   // 注意 /web
import { createClient } from '@libsql/client/web';  // 注意 /web
import * as schema from './schema';

export function createDb(url: string, authToken?: string) {
  const client = createClient({ url, authToken });
  return drizzle({ client, schema });
}

這段有兩個限制,都來自 app 執行於 Cloudflare Workers:

一、要用 /web 入口。 Cloudflare Workers 跑的是 workerd,不是 Node,因此沒有 Node 的原生模組。@libsql/client 的主入口帶了 Node 依賴,打包進 Worker 會失敗;/web 是純 fetch 版本,可以打包進 Worker。(drizzle-kit 和 seed 腳本跑在 Node,還要開啟本機檔案,因此使用主入口。兩者的差別在執行環境,不是資料庫。)

二、連線要在「請求當下」才建,不能在模組頂層。 若在檔案頂層用 const db = createDb(process.env.TURSO_DATABASE_URL) 建立共用 client,部署到 Cloudflare 時會失敗。Workers 在建置和模組載入期間拿不到 secret,此時建立的 client 會收到空網址。secret 要到「處理請求」時才能取得,因此連線要包成工廠函式,等請求進入 handler 後再呼叫。

在不同平台讀取 secret

連線需要 url 和 token,兩者都是 secret。Cloudflare runtime 不能沿用前面 drizzle-kit 的讀法。

舊教學仍可見 Astro.locals.runtime.env 的寫法。這個存取方式已在 @astrojs/cloudflare v13(對應 Astro 6)移除,v14/Astro 7 也沒有恢復,因此不應再使用。

Cloudflare 現在的官方做法是從一個虛擬模組拿:

import { env } from 'cloudflare:workers';
const url = env.TURSO_DATABASE_URL;

cloudflare:workers 可以使用,但它是 Cloudflare 專屬模組;部署到 Vercel 時,這段程式必須修改。為了保留跨平台部署能力,讀取 secret 也要避免平台專屬介面。Astro 內建的 astro:env 提供這種跨平台讀法。

astro.config.mjs 宣告一次有哪些環境變數、各是什麼性質:

import { defineConfig, envField } from 'astro/config';

export default defineConfig({
  env: {
    schema: {
      // server + secret:只在伺服器可讀、不會進到瀏覽器
      TURSO_DATABASE_URL: envField.string({ context: 'server', access: 'secret' }),
      TURSO_AUTH_TOKEN: envField.string({ context: 'server', access: 'secret', optional: true }),
    },
  },
  // …adapter 等其他設定
});

宣告完,app 裡就這樣拿:

import { TURSO_DATABASE_URL, TURSO_AUTH_TOKEN } from 'astro:env/server';

這一行 import 在 Cloudflare 和 Vercel 都能讀取 secret,各 adapter 會對接平台的 secret 機制。app 不必判斷目前部署在哪個平台。

至於 secret 實際放哪:Cloudflare 正式部署用 wrangler secret put TURSO_DATABASE_URL 設;本機開發放專案根目錄的 .dev.vars(記得加進 .gitignore,別上傳)。

把 Day 21 的 action 接上資料庫

回到 Day 21 只負責驗證的 handler,接上前面的資料庫連線,讓它寫入資料後再查回結果:

// src/actions/index.ts(handler 部分)
import { TURSO_DATABASE_URL, TURSO_AUTH_TOKEN } from 'astro:env/server';
import { avg, count, eq } from 'drizzle-orm';
import { createDb } from '../db';
import { feedback } from '../db/schema';

// …defineAction 的 input 驗證維持 Day 21 那套 Zod
handler: async ({ rating, message, slug }) => {
  const articleSlug = slug ?? 'unknown';
  const db = createDb(TURSO_DATABASE_URL, TURSO_AUTH_TOKEN);   // 請求當下才建連線

  // 落地這一筆
  await db.insert(feedback).values({ slug: articleSlug, rating, message });

  // 查回這篇目前的平均分與則數,一起回給前端
  const [stats] = await db
    .select({ average: avg(feedback.rating), total: count() })
    .from(feedback)
    .where(eq(feedback.slug, articleSlug));

  return { ok: true, slug: articleSlug, average: stats.average, total: stats.total };
},

Day 21 的 handler 只有驗證與回傳;這裡多了「存 + 查」兩步。使用者送出 feedback 後,回傳訊息會從「你剛送了 5 分」變成「這篇目前平均 X 分、共 Y 則」,這些數字來自資料庫的查詢結果。

和 Day 21 一樣,接資料庫的頁面(執行 action 或 endpoint)必須在收到請求時才執行,因此要設定 export const prerender = false。否則頁面會在 build 時被預先產生成靜態內容,無法接收請求。

先在本機用 file:local.db 寫入幾筆資料,再用 Drizzle 查回來:

=== day-21 的 feedback(新到舊)===
#1 [5★] Action 跟 Endpoint 的分界終於講清楚了。  (2026-07-20 22:59:55)
#2 [4★] 漸進增強那段很有用,沒 JS 也能送。  (2026-07-20 22:59:55)
=== 聚合 ===
平均 4.5 分、共 2 則

(時間戳是 UTC,這是 SQLite CURRENT_TIMESTAMP 的預設行為,不是 bug。)insertselectavgcount 這套查詢邏輯確實跑得起來,回來的 averagetotal 就是前端要顯示的數字。

接著用 @astrojs/cloudflare adapter 執行 npm run build,檢查整套寫法能否在 Cloudflare 上成立。結果通過:/web client、astro:env 與接上資料庫的 action 都成功打包。

本機要完整跑起來,得多起一個 turso dev

上面的 seed 使用 file:local.db 驗證查詢邏輯,但這和啟動 app、從瀏覽器送出表單是兩條不同路徑:app 無法直接連線到 local.db 檔案。

這項限制來自兩個入口的執行環境:app 使用 @libsql/client/web/web 入口、純 fetch),只能走 HTTP,無法開啟本機檔案;drizzle-kit 和 seed 使用 Node 主入口,才能開啟 file:local.db。兩邊的能力如下:

用哪個入口 開得了 file:local.db
db:migrate、seed(Node 工具) @libsql/client 主入口 開得了,直接讀寫檔案
app 的 action/endpoint(跑在 Workers) @libsql/client/web 開不了,只認 HTTP 端點

因此,只完成 local.db seed 就直接執行 npm run dev,表單仍然送不進去。app 需要 HTTP 網址,而 file:local.db 不是 HTTP 端點。可以用 Turso CLI 把同一個 local.db 檔在本機提供為 HTTP 服務:

npm run db:migrate            # 建表:這步走 file: 入口,直接寫進 local.db
turso dev --db-file local.db  # 把 local.db 服務成本機 HTTP 伺服器(預設 127.0.0.1:8080)

再把前面存 secret 的 .dev.vars 指到這個本機伺服器(本機不驗 token,留空即可),另開一個終端跑 app:

# .dev.vars
TURSO_DATABASE_URL=http://127.0.0.1:8080
TURSO_AUTH_TOKEN=
npm run dev

此時的資料鏈分成三層:

瀏覽器 ──▶ app(astro dev, :4321)──HTTP──▶ libsql 伺服器(turso dev, :8080)──開檔──▶ local.db

127.0.0.1:8080 不是資料庫,是資料庫的「HTTP 門口」——turso dev 起的伺服器進程,背後才是 local.db。這三層全在你這台機器上。那段 HTTP 走的是 loopback,不碰外部網路、離線也跑得動;用 HTTP 只是因為 app 端的 client 只會講這個協定,不代表資料送出去了。

turso dev 讓本機與 production 使用相同的連線方式。上線時不必改動程式,只要把 .dev.vars 裡的 http://127.0.0.1:8080 換成雲端的 libsql://…turso.io,再補上 token。

幾個容易誤會的地方

  • Turso 跟 SQLite 不是兩種東西。 Turso 託管的就是 libSQL、libSQL 是 SQLite 的分支。你不用學新的 SQL,原本的知識直接用。
  • 哪些檔案該進版控。 migration 檔(migrations/)要進,那是 schema 的歷史;本機的 local.db 和裝 secret 的 .dev.vars 不要進。
  • astro:env 的 secret 是 build 時不檢查、runtime 才讀。 好處是本機沒設 secret 也 build 得過;代價是線上忘了設,會等到請求進來那一刻才抓不到值。部署前記得確認 secret 設了。
  • dialect 別填錯。 連 Turso 用 'turso''sqlite' 是給本機 better-sqlite3 那種檔案型資料庫的,填錯 drizzle-kit 會走錯連線方式。
  • avg() 回的是字串。 SQL 聚合函式回來的平均值是字串型別,前端要顯示成「4.5」記得自己轉數字、控小數位。

接下來:辨認是誰送出資料

這裡選 Turso 而非 D1,並以 astro:env 取代 cloudflare:workers,是為了讓同一套資料存取程式能跨平台沿用。若不需要轉移部署平台,D1 仍是設定更少的選項。

資料存得進、查得回了,但還不知道這次請求是誰送的、他登入了沒。Day 23 會用 middleware 和 locals,在請求進到 handler 前辨認使用者;Day 24 再接上登入與收藏。

本日程式碼:step-22|只看這天的改動:step-21...step-22


上一篇
Astro 收表單,什麼時候用 Action、什麼時候自己寫 API?
系列文
用 Astro 打造 Content-first 前端網站:30 天從靜態內容到會員、資料庫與選型(3rd)22
圖片
  熱門推薦
圖片
{{ item.channelVendor }} | {{ item.webinarstarted }} |
{{ formatDate(item.duration) }}
直播中

尚未有邦友留言

立即登入留言